bullpen run --json: consume a run as a stream instead of scraping a terminal - #12
Conversation
… terminal A program driving `bullpen run` had nothing to read. The final answer arrived as raw text deltas on stdout with no framing, tool activity was human prose gated behind -v on stderr, and the Event channel the loop already publishes had three of its four variants discarded by the CLI printer. Completion could only be inferred from stdout closing, which is indistinguishable from a crash. With --json, stdout carries one JSON object per line, flushed as each event happens so a consumer can act mid-flight, and the last line is always a `result` object carrying the session id, the final text, cumulative usage and an error flag. Without the flag nothing about the output changes. Notes: - The event kind strings live in crates/cli/src/json.rs as `const KIND_*`, hand-written rather than derived from the `Event` variant names. Once anything reads this stream the strings are a compatibility surface, and the crate that owns the wire should be the crate that owns the names — renaming an internal variant must not move a wire value. `event_json` is exhaustive on `Event` so a fifth variant fails to compile rather than silently vanishing from the stream. - The text-delta sink stays attached under --json and its text is dropped. Detaching it would flip the agent from complete_streaming to complete, a behavior change hidden behind an output flag. Deltas are not events; the per-turn AssistantText is the only text event. - Tool payloads are capped against bullpen-agent's MAX_TOOL_RESULT_BYTES, which this makes pub rather than duplicating the literal. The cap is genuinely needed here: run_tool_with_events emits ToolEnd with the *uncapped* output, and cap_result only applies as a result enters the transcript. The `result` text is deliberately not capped — that is the deliverable, not a payload. - `--bg --json` prints one `dispatched` object rather than a terminal object. A detached run has no stream and no completion to report. - --json is not forwarded across the dispatch boundary into the child, for the same reason -v is not: the child's stdout and stderr share one log file, so NDJSON there would interleave with prose. Refs #9 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PfAfAujueuZ3rDTiL9apx3
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThe CLI adds ChangesNDJSON event streaming
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Run as bullpen run --json
participant Consumer as CLI event consumer
participant Serializer as cli::json serializers
participant Stdout
Run->>Consumer: enable JSON event handling
Consumer->>Serializer: serialize agent event
Serializer->>Stdout: emit one flushed JSON line
Run->>Serializer: serialize terminal result
Serializer->>Stdout: emit result JSON line
Possibly related issues
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
|
|
@coderabbitai review |
|
Closes #9.
What was impossible
Nothing could drive
bullpen runprogrammatically. The final answer reachedstdout as unframed text deltas, tool activity was human prose behind
-vonstderr, and the
Eventchannel the loop already publishes had three of itsfour variants dropped on the floor by the CLI printer. Worst of all, a
consumer could only learn a run had finished by watching stdout close — which
looks exactly like a crash, a killed process, or a broken pipe.
With
--json, stdout is newline-delimited JSON and nothing else: one objectper event, written and flushed as the event happens, with a terminal
resultobject carrying the session id, the final text, cumulative usage, an
errorboolean and the provider message. Success and failure both produce it, and it
is always last. Prose, the
[session … tokens]footer, the recovery noticeand
-vtool activity all stay on stderr, so--jsonand-vcompose.Without the flag, output is unchanged.
Decisions a reviewer would otherwise have to reverse-engineer
The kind strings are hand-written, not derived.
KIND_ASSISTANT_TEXT,KIND_TOOL_START,KIND_TOOL_END,KIND_TURN_DONE,KIND_RESULT,KIND_DISPATCHEDareconst &strincrates/cli/src/json.rs, and the testsassert them as string literals. No
stringify!, no serde variant-namederivation, no
#[derive(Serialize)]onEvent.bullpen_agent::Eventisinternal state; the wire is a compatibility surface the moment anything reads
it, so the CLI owns the names and an internal rename cannot move them.
event_jsonmatchesEventexhaustively, so a fifth variant is a compileerror rather than a silent gap in the stream.
The text-delta sink stays attached and its text is discarded. Detaching
it would flip the agent from
complete_streamingtocomplete— a realbehavior change smuggled in behind an output flag. Text deltas are not
emitted as JSON; the per-turn
AssistantTextevent is the only text event, sothe stream stays one-object-per-semantic-event rather than a token firehose.
Tool-payload capping is required, not incidental.
run_tool_with_eventsemits
ToolEndwith the uncapped output;cap_resultonly runs as a resultenters the transcript. Rather than duplicate the literal, this makes
MAX_TOOL_RESULT_BYTESpubso both paths share one number.tool_end.outputtruncates on a char boundary with anoutput_truncatedflag;
tool_start.inputis the JSON value verbatim when its serializationfits and the truncated serialization string plus
input_truncatedwhen itdoes not. The
resultobject'stextis deliberately not capped — theissue caps tool inputs and outputs, and truncating the final answer would
silently corrupt the deliverable.
errorandmessageare two fields. The issue asked for "an error flag".One field cannot be both a boolean a consumer branches on and a carrier for
the provider's message, so
resulthas"error": <bool>alongside"message": <string|null>. OnAgentError::Truncated,textcarries thepartial rather than being blanked.
--bg --jsonprints onedispatchedobject ({"kind":"dispatched", "session_id":…,"pid":…}) in place of thedispatched <id>line. A detachedrun has no stream and no completion, so a terminal object would be a lie. The
flag is not forwarded into the child, for the same reason
-vis not: thechild's stdout and stderr share one log file, so NDJSON there would interleave
with prose.
Errors still exit nonzero with an anyhow line on stderr. The stream gets
its terminal object so completion is never inferred from EOF, but the exit
code remains the authority — extending the precedent set by
sessions --jsonin #6 rather than contradicting it.
Shape follows
sessions --json: a module of pure-> serde_json::Valuebuilders owned by the CLI, hand-built with
json!, unit-tested withouttouching disk.
run()picks between the existing human printer and a JSONwriter that does
write_all+flushper line on a single stdout handle, soline order is the order the loop produced events in.
Verification
All three CI gates clean on the pinned toolchain (
rustc 1.97.1):cargo fmt --all --check,cargo clippy --workspace --all-targets -D warnings,cargo test --workspace— 108 passed, 0 failed, 10 new (thebullpencrate goes 4 → 14). Clippy was re-run after touching the threechanged source files, since the first invocation finished off cache.
Manual, against the built binary with
BULLPEN_HOMEpointed at a scratch dirand
-p openrouter:tool_startat +3s,resultat +10s, across a 6ssleepin a bash call--bg --json{"kind":"dispatched","pid":…,"session_id":<full uuid>}--bgwithout--jsondispatched a11a38f9 (pid 84880)— unchanged-m no/such-model)resultwith"error":true+ provider message; stderr = the anyhow line; exit 1run -vhello\n(od-verified); footer and tool prose on stderrKnown gap
No test executes the binary, so "stdout is NDJSON and nothing else" and the
byte-for-byte-unchanged unflagged path rest on the diff shape (a
boolguardaround existing
println!s) plus the manual runs above. Closing that wouldadd the workspace's first
[[test]]target and[dev-dependencies]oncrates/cli— a new convention, and the same call #6 made.Summary by CodeRabbit
bullpen run --jsonfor newline-delimited JSON event streaming.